--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 2925daacfe5091f360658a3b28c2c636f53aa58d
Parents : 260d0dc
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-23T14:28:52-05:00
feat(reactions): implement conversation list preview for messages with reactions
Changes
5 files changed, 183 insertions(+), 3 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 2752d2ea..cef1bff9 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -92,6 +92,7 @@ from meshchatx.src.backend.lxmf_utils import (
convert_lxmf_state_to_string,
is_user_facing_lxmf_payload,
lxmf_fields_are_columba_reaction,
+ lxmf_sidebar_preview_for_conversation_latest_row,
)
from meshchatx.src.backend.map_manager import MAX_EXPORT_TILES, TRANSPARENT_TILE
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
@@ -10052,7 +10053,15 @@ class ReticulumMeshChat:
row["fields"],
),
"latest_message_title": row["title"],
- "latest_message_preview": row["content"],
+ "latest_message_preview": lxmf_sidebar_preview_for_conversation_latest_row(
+ row,
+ local_hash=local_hash,
+ peer_display_name=(
+ row.get("custom_display_name")
+ or display_name
+ or "Anonymous Peer"
+ ),
+ ),
"latest_message_created_at": row["timestamp"],
"lxmf_user_icon": user_icon,
"is_contact": bool(row.get("is_contact", 0)),
diff --git a/meshchatx/src/backend/lxmf_utils.py b/meshchatx/src/backend/lxmf_utils.py
index 291e2d08..c02fcc1f 100644
--- a/meshchatx/src/backend/lxmf_utils.py
+++ b/meshchatx/src/backend/lxmf_utils.py
@@ -95,6 +95,61 @@ def is_user_facing_lxmf_payload(fields, content, title) -> bool:
return False
+def _reaction_emoji_from_parsed_lxmf_fields(fields: dict) -> str | None:
+ if not isinstance(fields, dict):
+ return None
+ app = fields.get("app_extensions")
+ if isinstance(app, dict) and "reaction_to" in app:
+ emoji = (app.get("emoji") or "").strip()
+ return emoji or None
+ raw = fields.get(LXMF_APP_EXTENSIONS_FIELD)
+ if isinstance(raw, dict) and "reaction_to" in raw:
+ emoji = (raw.get("emoji") or "").strip()
+ return emoji or None
+ return None
+
+
+def lxmf_sidebar_preview_for_conversation_latest_row(
+ row: dict,
+ *,
+ local_hash: str,
+ peer_display_name: str,
+) -> str:
+ """Single-line preview for conversation list APIs (reactions have empty body)."""
+ content = row.get("content")
+ if content is not None and str(content).strip():
+ return str(content)
+
+ fields_raw = row.get("fields")
+ try:
+ if isinstance(fields_raw, str):
+ fields = json.loads(fields_raw) if fields_raw else {}
+ elif isinstance(fields_raw, dict):
+ fields = fields_raw
+ else:
+ fields = {}
+ except (json.JSONDecodeError, TypeError):
+ fields = {}
+
+ emoji = _reaction_emoji_from_parsed_lxmf_fields(fields)
+ if not emoji:
+ return str(content or "")
+
+ is_incoming = bool(row.get("is_incoming"))
+ if is_incoming:
+ actor = peer_display_name or "Anonymous Peer"
+ else:
+ src = (row.get("source_hash") or "").lower()
+ loc = (local_hash or "").lower()
+ actor = (
+ "You"
+ if src and loc and src == loc
+ else (peer_display_name or "Anonymous Peer")
+ )
+
+ return f"{actor} reacted {emoji}"
+
+
def convert_lxmf_message_to_dict(
lxmf_message: LXMF.LXMessage,
include_attachments: bool = True,
diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index 8c6b8f79..904c0005 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -259,6 +259,7 @@ function snapshotGlobalConfig() {
import DialogUtils from "../../js/DialogUtils";
import GlobalEmitter from "../../js/GlobalEmitter";
import ToastUtils from "../../js/ToastUtils";
+import { lxmfConversationListPreview } from "../../js/lxmfReactions";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
@@ -671,12 +672,26 @@ export default {
peerHashFromMessage(msg) {
return msg.is_incoming ? msg.source_hash : msg.destination_hash;
},
+ peerDisplayNameForConversationSidebar(peerHash) {
+ const conv = this.conversations.find((c) => c.destination_hash === peerHash);
+ if (conv) {
+ return conv.custom_display_name ?? conv.display_name ?? "Anonymous Peer";
+ }
+ const peer = this.peers[peerHash];
+ return peer?.custom_display_name ?? peer?.display_name ?? "Anonymous Peer";
+ },
onOutboundMessageCreated(msg) {
const peerHash = this.peerHashFromMessage(msg);
+ const peerDisplay = this.peerDisplayNameForConversationSidebar(peerHash);
+ const preview = lxmfConversationListPreview(msg, {
+ myLxmfAddressHash: this.config?.lxmf_address_hash || "",
+ peerDisplayName: peerDisplay,
+ t: this.$t.bind(this),
+ });
const idx = this.conversations.findIndex((c) => c.destination_hash === peerHash);
if (idx !== -1) {
const conv = this.conversations[idx];
- conv.latest_message_preview = msg.content;
+ conv.latest_message_preview = preview;
conv.latest_message_title = msg.title;
conv.latest_message_created_at = msg.timestamp;
conv.updated_at = new Date(msg.timestamp * 1000).toISOString();
@@ -692,7 +707,7 @@ export default {
is_tracking: peer?.is_tracking ?? false,
failed_messages_count: 0,
has_attachments: false,
- latest_message_preview: msg.content,
+ latest_message_preview: preview,
latest_message_title: msg.title,
latest_message_created_at: msg.timestamp,
updated_at: new Date(msg.timestamp * 1000).toISOString(),
diff --git a/meshchatx/src/frontend/js/lxmfReactions.js b/meshchatx/src/frontend/js/lxmfReactions.js
index f99620f7..3de0d411 100644
--- a/meshchatx/src/frontend/js/lxmfReactions.js
+++ b/meshchatx/src/frontend/js/lxmfReactions.js
@@ -11,6 +11,54 @@ export const COLUMBA_REACTION_EMOJIS = [
"\u{1F621}",
];
+function reactionEmojiFromLxmfMessageFields(fields) {
+ if (!fields || typeof fields !== "object") {
+ return "";
+ }
+ const app = fields.app_extensions;
+ if (app && typeof app === "object" && app.reaction_to) {
+ return typeof app.emoji === "string" ? app.emoji : "";
+ }
+ return "";
+}
+
+/**
+ * One-line preview for conversation list / sidebar (plain text or i18n via `t`).
+ */
+export function lxmfConversationListPreview(msg, { myLxmfAddressHash, peerDisplayName, t }) {
+ const raw = msg?.content;
+ const content = typeof raw === "string" ? raw.trim() : "";
+ if (content) {
+ return raw;
+ }
+
+ const emoji =
+ (msg?.is_reaction && typeof msg?.reaction_emoji === "string" && msg.reaction_emoji) ||
+ reactionEmojiFromLxmfMessageFields(msg?.fields);
+ if (!emoji) {
+ return raw ?? "";
+ }
+
+ const incoming = Boolean(msg?.is_incoming);
+ const src = String(msg?.source_hash || "").toLowerCase();
+ const me = String(myLxmfAddressHash || "").toLowerCase();
+ const reactorIsYou = !incoming && me && src === me;
+
+ let name;
+ if (incoming) {
+ name = peerDisplayName || "Anonymous Peer";
+ } else if (reactorIsYou) {
+ name = typeof t === "function" ? t("messages.reaction_you") : "You";
+ } else {
+ name = peerDisplayName || "Anonymous Peer";
+ }
+
+ if (typeof t === "function") {
+ return t("messages.conversation_reaction_preview", { name, emoji });
+ }
+ return `${name} reacted ${emoji}`;
+}
+
export function mergeLxmfReactionRowsIntoMessages(messages) {
if (!Array.isArray(messages) || messages.length === 0) {
return messages;
diff --git a/tests/backend/test_lxmf_utils_extended.py b/tests/backend/test_lxmf_utils_extended.py
index ea2c1683..24f39c78 100644
--- a/tests/backend/test_lxmf_utils_extended.py
+++ b/tests/backend/test_lxmf_utils_extended.py
@@ -12,6 +12,7 @@ from meshchatx.src.backend.lxmf_utils import (
convert_db_lxmf_message_to_dict,
convert_lxmf_message_to_dict,
convert_lxmf_state_to_string,
+ lxmf_sidebar_preview_for_conversation_latest_row,
)
@@ -251,3 +252,55 @@ def test_compute_unread_incoming_newer_than_read_cursor_unread():
"timestamp": ts,
}
assert compute_lxmf_conversation_unread_from_latest_row(row) is True
+
+
+def test_sidebar_preview_reaction_incoming_uses_peer_name():
+ local = "a" * 32
+ row = {
+ "content": "",
+ "fields": json.dumps(
+ {"app_extensions": {"reaction_to": "abc123", "emoji": "\U0001f44d"}},
+ ),
+ "is_incoming": 1,
+ "source_hash": "b" * 32,
+ }
+ out = lxmf_sidebar_preview_for_conversation_latest_row(
+ row,
+ local_hash=local,
+ peer_display_name="Charlie",
+ )
+ assert out == "Charlie reacted \U0001f44d"
+
+
+def test_sidebar_preview_reaction_outbound_from_self_is_you():
+ me = "c" * 32
+ row = {
+ "content": "",
+ "fields": json.dumps(
+ {"app_extensions": {"reaction_to": "abc123", "emoji": "\u2764\ufe0f"}},
+ ),
+ "is_incoming": 0,
+ "source_hash": me,
+ }
+ out = lxmf_sidebar_preview_for_conversation_latest_row(
+ row,
+ local_hash=me,
+ peer_display_name="Dana",
+ )
+ assert out == "You reacted \u2764\ufe0f"
+
+
+def test_sidebar_preview_prefers_non_empty_content():
+ row = {
+ "content": " hi ",
+ "fields": json.dumps(
+ {"app_extensions": {"reaction_to": "abc123", "emoji": "\U0001f44d"}},
+ ),
+ "is_incoming": 1,
+ }
+ out = lxmf_sidebar_preview_for_conversation_latest_row(
+ row,
+ local_hash="a" * 32,
+ peer_display_name="Eve",
+ )
+ assert out == " hi "
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────